Skip to main content
  1. SwiftUI in 100 Days Notes/

Day 57 - SwiftUI and SwiftData

In our last project we looked at using SwiftData with SwiftUI, in this project we’ll go into more detail: we’ll look at things like custom managed object subclasses and ensuring uniqueness.

Today we have three topics where we’ll learn how to organize SwiftData objects with SwiftUI, how to filter your data using #Predicate, and more.

  1. SwiftData Introduction
  2. Editing SwiftData Model Objects
  3. Filtering @Query using Predicate

SwiftData Introduction #

This technical project will explore SwiftData in more detail, starting with a summary of some basic techniques and progressing to tackling more complex problems.

As you can see, SwiftData really pushes the advanced features of both Swift and SwiftUI to make it easier for us to store data efficiently. It’s not always easy though, and there are a few places that require some thought to use it in the right way.

We have a lot to explore, so please create a new project that we can try out. Call it “SwiftDataProject”, not “SwiftData” because that would confuse Xcode.

Make sure you don’t have SwiftData for Stroge enabled. Again, we’ll create this from scratch so you can see how everything works.

Editing SwiftData Model Objects #

SwiftData’s model objects are supported by the same observation system that makes the @Observable classes work, which means that changes to your model objects will be automatically picked up by SwiftUI, so our data and UI stay in sync.

This support extends to the @Bindable property wrapper we looked at earlier.

To demonstrate this, we can create a simple User class with a few properties. Create a new file called User.swift, add an import at the top for SwiftData and then add this code;

@Model
class User {
    var name: String
    var city: String
    var joinDate: Date

    init(name: String, city: String, joinDate: Date) {
        self.name = name
        self.city = city
        self.joinDate = joinDate
    }
}

Now we can create the model container and model context for it by adding another import SwiftData to the App struct file and then using modelContainer() as follows.

WindowGroup {
    ContentView()
}
.modelContainer(for: User.self)

When it comes to editing user objects, we create a new view with a name like EditUserView, then use the @Bindable property wrapper to create the binding for it. So, something like this;

struct EditUserView: View {
    @Bindable var user: User

    var body: some View {
        Form {
            TextField("Name", text: $user.name)
            TextField("City", text: $user.city)
            DatePicker("Join Date", selection: $user.joinDate)
        }
        .navigationTitle("Edit User")
        .navigationBarTitleDisplayMode(.inline)
    }
}

This is the same as how we use a normal @Observable class, and yet SwiftData continues to automatically write all our changes to persistent storage - this is completely transparent to us.

Add the following code to be able to use Xcode’s Preview;

#Preview {
    do {
        let config = ModelConfiguration(isStoredInMemoryOnly: true)
        let container = try ModelContainer(for: User.self, configurations: config)
        let user = User(name: "Taylor Swift", city: "Nashville", joinDate: .now)
        return EditUserView(user: user)
            .modelContainer(container)
    } catch {
        return Text("Failed to create container: \(error.localizedDescription)")
    }
}

We can make a really simple user editing application out of this by adding a new user at the press of a button and then using programmatic navigation to take the application directly to the new user for editing.

Let’s build this step by step. First open ContentView.swift and add an import for SwiftData, load all User objects, then store a path that we can bind to a NavigationStack.

@Environment(\.modelContext)var modelContext
@Query(sort: \User.name)var users: [User]
@Stateprivatevar path = [User]()

body property’yi aşağıdaki gibi düzenleyin;

NavigationStack(path: $path) {
    List(users) { user in
        NavigationLink(value: user) {
            Text(user.name)
        }
    }
    .navigationTitle("Users")
    .navigationDestination(for: User.self) { user in
        EditUserView(user: user)
    }
}

And now we just need a way to add users. If you think about it, adding and fixing are very similar, so the easiest thing to do here is to create a new User object with empty properties, add it to the model context and then immediately go to it by setting the path property.

Add the following two modifiers below the navigation modifier;

.toolbar {
    Button("Add User", systemImage: "plus") {
        let user = User(name: "", city: "", joinDate: .now)
        modelContext.insert(user)
        path = [user]
    }
}

As you can see, editing with SwiftData objects is no different from editing regular @Observable classes - it’s just an added advantage that all our data is properly loaded and saved.

@Query Filtering using Predicate #

You’ve already seen how @Query can be used to sort SwiftData objects in a specific order, but this can also be used to filter data.

The syntax of this sounds a bit strange at first, because this is actually another macro behind the scenes. It turns our Swift code into a set of rules that SwiftData can apply to the underlying database that stores all its objects.

Let’s start with something simple, using the User model we used earlier;

@Model
class User {
    var name: String
    var city: String
    var joinDate: Date

    init(name: String, city: String, joinDate: Date) {
        self.name = name
        self.city = city
        self.joinDate = joinDate
    }
}

Now we can add a few properties to the ContentView that can show all the users we have;

@Environment(\.modelContext) var modelContext
@Query(sort: \User.name) var users: [User]

And finally, we can show all these users in a list and we will also add a button to easily add some sample data;

NavigationStack {
    List(users) { user in
        Text(user.name)
    }
    .navigationTitle("Users")
    .toolbar {
        Button("Add Samples", systemImage: "plus") {
            let first = User(name: "Ed Sheeran", city: "London", joinDate: .now.addingTimeInterval(86400 * -10))
            let second = User(name: "Rosa Diaz", city: "New York", joinDate: .now.addingTimeInterval(86400 * -5))
            let third = User(name: "Roy Kent", city: "London", joinDate: .now.addingTimeInterval(86400 * 5))
            let fourth = User(name: "Johnny English", city: "London", joinDate: .now.addingTimeInterval(86400 * 10))

            modelContext.insert(first)
            modelContext.insert(second)
            modelContext.insert(third)
            modelContext.insert(fourth)
        }
    }
}

Tip : These join dates represent some number of days in the past or future, which gives us interesting data to work with.

When working with sample data like this, it is useful to be able to delete the existing series before adding the sample data. To do this, add the following code before the let first = line:

try? modelContext.delete(model: User.self)

This tells SwiftData to delete all existing model objects of type User, which means that the database is clean before adding sample users.

To complete our little sample application, we need to make sure that the App struct uses the modelContainer() modifier to set up SwiftData correctly:

WindowGroup {
    ContentView()
}
.modelContainer(for: User.self)

Now go ahead and run the application, then press the + button to add four users.

You can see that they appear in alphabetical order, because that’s what we wanted in our @Query property.

Now let’s try filtering this data so that we only show users with a capital R in their name. To do this we apply a filter parameter to @Query as follows;

@Query(filter: #Predicate<User> { user in
    user.name.contains("R")
}, sort: \User.name) var users: [User]

Let’s explain this a little bit;

  1. The filter starts with #Predicate<User>, which means we are writing a predicate.
  2. This predicate gives us a single user instance to check. In practice, this will be called once for each user loaded by SwiftData and we need to return true if this user should be included in the results.
  3. Our test checks if the user’s name contains the letter R. If it does, the user is included in the results, otherwise it is not.

Now run the code and you will see that both Rosa and Roy appear in our list, but Ed and Johnny’s names are not included because they do not contain a capital R. The contains() method is case sensitive, so Ed Sheeran, who has a lowercase r, was not included in the results.

This works great for a simple predicate test, but it’s very rare that users really care about capitalization. They usually just want to type a few letters and look for that match anywhere in the results, ignoring uppercase and lowercase letters.

For this purpose, iOS provides us with a separate localizedStandardContains() method. This also takes a string to search for, but automatically ignores case, so it’s a much better option when you’re trying to filter by user text.

This is what it looks like;

@Query(filter: #Predicate<User> { user in
    user.name.localizedStandardContains("R")
}, sort: \User.name) var users: [User]

In our small test data, this means that we will see three out of four users, because these three have the letter “r” somewhere in their name.

Now let’s go one step further: Let’s increase our filter to match people with the letter “R” in their name who live in London;

@Query(filter: #Predicate<User> { user in
    user.name.localizedStandardContains("R") &&
    user.city == "London"
}, sort: \User.name) var users: [User]

This uses Swift’s “logical and” operator. In this case, results with the letter r in the name and living in London are filtered out.

You can add more checks like this, but using && is a bit confusing. Fortunately, these predicates support a limited subset of Swift expressions, which makes them a little easier to read.

For example, we can rewrite the existing predicate as follows;

@Query(filter: #Predicate<User> { user in
    if user.name.localizedStandardContains("R") {
        if user.city == "London" {
            return true
        } else {
            return false
        }
    } else {
        return false
    }
}, sort: \User.name) var users: [User]

Now, you might be thinking that this is a bit verbose. You can remove both else blocks and just end with return true, because if the user actually matches the predicate, return true will already be provided.

This is how it will look like;

@Query(filter: #Predicate<User> { user in
    if user.name.localizedStandardContains("R") {
        if user.city == "London" {
            return true
        }
    }

    return false
}, sort: \User.name) var users: [User]

Unfortunately this code isn’t actually valid, because it’s important to remember that even though it looks like we’re running pure Swift code, it’s not actually happening. The #Predicate macro actually rewrites our code to be a set of tests that it can apply to the database that doesn’t use Swift internally.

To see what happens internally, undo the changes (⌘ + Z) . Now right click on #Predicate and select Expand Macro and you will see a large amount of code appear. Remember, this is the actual code that will be generated and executed.


You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.

This article contains the notes I took for myself from the articles found at SwiftUI Day 57. Please use the link to follow the original lesson.